package ru.batrdmi.svnplugin.logic;

import com.intellij.openapi.diagnostic.Logger;
import com.intellij.openapi.progress.ProgressIndicator;
import com.intellij.openapi.vfs.VirtualFile;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.idea.svn.SvnVcs;
import org.tmatesoft.svn.core.*;
import org.tmatesoft.svn.core.internal.util.SVNDate;
import org.tmatesoft.svn.core.io.SVNCapability;
import org.tmatesoft.svn.core.io.SVNRepository;
import org.tmatesoft.svn.core.wc.SVNInfo;
import org.tmatesoft.svn.core.wc.SVNRevision;

import java.util.*;

import static org.tmatesoft.svn.core.io.SVNRepository.INVALID_REVISION;

public class FileHistoryRetriever {
    private static final Logger log = Logger.getInstance("ru.batrdmi.svnplugin.logic.FileHistoryRetriever");

    private final SvnVcs svn;
    private VirtualFile file;

    public FileHistoryRetriever(SvnVcs svn, VirtualFile file) {
        this.svn = svn;
        this.file = file;
    }

    public FileRevisionHistory getFileHistory(ScanMode scanMode, final ProgressIndicator progressIndicator)
            throws SVNException {
        boolean retrievedWithErrors = false;
        final CollectedRevisions revisions = new CollectedRevisions();
        SVNInfo info = svn.getInfo(file);
        if (info == null) {
            throw new SVNException(SVNErrorMessage.create(SVNErrorCode.UNKNOWN, "Error retrieving SVN information for file"));
        }
        SVNRevision committedRevision = info.getRevision();
        if (committedRevision == null) {
            throw new SVNException(SVNErrorMessage.create(SVNErrorCode.UNKNOWN, "Error determining current file revision"));
        }
        boolean isDirectory = info.getKind() != SVNNodeKind.FILE;
        final long currentRevision = committedRevision.getNumber();
        String repoRoot = info.getRepositoryRootURL().toString();
        final String relPath = info.getURL().toString().substring(repoRoot.length());

        SVNRepository repo = svn.createRepository(repoRoot);
        boolean mergeInfoAvailable = repo.hasCapability(SVNCapability.MERGE_INFO);

        showProgressInfo(progressIndicator, "Getting latest repository revision");
        long latestRevision = repo.getLatestRevision();

        ExtendedScanManager esm = new ExtendedScanManager(repo, latestRevision, progressIndicator);
        final Deque<Task> pathsToProcess = new LinkedList<Task>();
        boolean retryWithoutMergeInfo = false;

        pathsToProcess.add(new Task(relPath, currentRevision, true));
        while (!pathsToProcess.isEmpty()) {
            checkCancelled(progressIndicator);
            final Task task = pathsToProcess.remove();
            if (revisions.contain(task.relPath, task.revision)) {
                continue;
            }

            if (scanMode != ScanMode.ONLY_IMPACTING_PATHS) {
                pathsToProcess.addAll(esm.getPotentialTargets(task.relPath, scanMode == ScanMode.INCLUDE_CURRENT_BRANCHES_AND_TAGS));
                checkCancelled(progressIndicator);
            }

            try {
                showProgressInfo(progressIndicator, "Obtaining log info for " + task.relPath);
                final List<Revision> revisionChain = new ArrayList<Revision>();
                final List<Task> newTasks = new ArrayList<Task>();
                // find deleted revision for current scan point to determine limits of log request
                long deletedRevision = (task.revision == latestRevision) ? INVALID_REVISION
                        : repo.getDeletedRevision(task.relPath, task.revision, latestRevision);
                if (deletedRevision >= 0) {
                    checkCancelled(progressIndicator);
                    SVNProperties p = repo.getRevisionProperties(deletedRevision, null);
                    revisionChain.add(new Revision(task.relPath, deletedRevision,
                            p.getStringValue(SVNRevisionProperty.AUTHOR),
                            SVNDate.parseDateString(p.getStringValue(SVNRevisionProperty.DATE)),
                            p.getStringValue(SVNRevisionProperty.LOG),
                            Collections.<Revision>emptyList(), null, SVNLogEntryPath.TYPE_DELETED));
                }

                // request log for current scan point
                MergeInfoProcessor handler = new MergeInfoProcessor(task.relPath, new LogEntryWithMergeInfoHandler() {
                    @Override
                    public void handleLogEntry(SVNLogEntry logEntry, @NotNull List<Revision> mergedRevisions)
                            throws SVNCancelException {
                        checkCancelled(progressIndicator);
                        Revision.LinkType linkType = null;
                        // check copy sources
                        Revision copyFromRevision = getCopyFromRevision(logEntry, task.relPath);
                        if (copyFromRevision != null) {
                            linkType = Revision.LinkType.COPY;
                            mergedRevisions = Arrays.asList(copyFromRevision);
                        } else if (!mergedRevisions.isEmpty()) {
                            linkType = Revision.LinkType.MERGE;
                        }
                        long revision = logEntry.getRevision();
                        char changeType = getChangeType(logEntry, task.relPath);
                        Revision rgRev = new Revision(task.relPath, revision,
                                logEntry.getAuthor(), logEntry.getDate(), logEntry.getMessage(),
                                mergedRevisions, linkType, changeType);
                        revisionChain.add(rgRev);
                        if (!mergedRevisions.isEmpty()) {
                            Revision linkRev = mergedRevisions.get(0);
                            newTasks.add(new Task(linkRev.getRelPath(), linkRev.getRevisionNumber(), true));
                        }
                        if (changeType == SVNLogEntryPath.TYPE_REPLACED) {
                            newTasks.add(new Task(task.relPath, revision - 1, true));
                        }
                        showProgressInfo(progressIndicator, rgRev + " processed");
                    }
                });
                repo.log(new String[]{task.relPath}, deletedRevision < 0 ? INVALID_REVISION : (deletedRevision - 1), 0,
                        true, true, 0, mergeInfoAvailable && !retryWithoutMergeInfo , null, handler);
                handler.checkFinalState();
                revisions.addRevisionChain(revisionChain);
                pathsToProcess.addAll(newTasks);
                retryWithoutMergeInfo = false;
            } catch (SVNCancelException e) {
                throw e;
            } catch (MalformedSVNResponseException e) {
                if (retryWithoutMergeInfo) {
                    throw e; // should not happen
                }
                log.warn("Malformed mergeinfo returned for " + task + ". Requesting history for it again, without mergeinfo");
                retrievedWithErrors = true;
                retryWithoutMergeInfo = true;
                pathsToProcess.addFirst(task);
            } catch (SVNException e) {
                if (!task.errorIfAbsent && e.getErrorMessage().getErrorCode() == SVNErrorCode.FS_NOT_FOUND) {
                    log.info(task + " not found. Probably the target is absent in corresponding branch or tg");
                } else {
                    log.error("Error processing revisions for " + task, e);
                    retrievedWithErrors = true;
                    retryWithoutMergeInfo = false;
                }
            }
        }
        // determining actual current revision
        Revision currentRev = revisions.getActualRevision(relPath, currentRevision);

        return new FileRevisionHistory(revisions.getRevisions(), currentRev, repoRoot, relPath, isDirectory,
                new FileRevisionHistory.RetrievalStatus(retrievedWithErrors, !mergeInfoAvailable));
    }

    private static boolean isAffectingPath(String targetPath, String changePath) {
        return targetPath.startsWith(changePath);
    }

    @SuppressWarnings("unchecked")
    private static Revision getCopyFromRevision(SVNLogEntry le, String targetPath) {
        Map<String, SVNLogEntryPath> changedPaths = le.getChangedPaths();
        String longestAffectingPath = "";
        Revision result = null;
        for (SVNLogEntryPath logEntryPath : changedPaths.values()) {
            String path = logEntryPath.getPath();
            String copyPath = logEntryPath.getCopyPath();
            if (copyPath != null && isAffectingPath(targetPath, path)
                    && path.length() >= longestAffectingPath.length()) {
                String copyFromPath = copyPath + targetPath.substring(path.length());
                long copyFromRevision = logEntryPath.getCopyRevision();
                result = new Revision(copyFromPath, copyFromRevision);
                longestAffectingPath = path;
            }
        }
        return result;
    }

    @SuppressWarnings("unchecked")
    private static char getChangeType(SVNLogEntry le, String targetPath) {
        String longestAffectingPath = "";
        boolean targetPathWasAdded = false;
        char changeType = SVNLogEntryPath.TYPE_MODIFIED;
        // finding change type for most 'precise' entry path (target path itself or nearest enclosing folder)
        Map<String, SVNLogEntryPath> changedPaths = le.getChangedPaths();
        for (SVNLogEntryPath logEntryPath : changedPaths.values()) {
            String entryPath = logEntryPath.getPath();
            char entryChangeType = logEntryPath.getType();
            if (targetPath.startsWith(entryPath)) {
                if (entryChangeType == SVNLogEntryPath.TYPE_ADDED) {
                    targetPathWasAdded = true;
                }
                if (entryPath.length() >= longestAffectingPath.length()) {
                    changeType = entryChangeType;
                    longestAffectingPath = entryPath;
                }
            }
        }
        // correction for SVN 'feature': R type doesn't always indicate replacement
        if (changeType == SVNLogEntryPath.TYPE_REPLACED && targetPathWasAdded) {
            changeType = SVNLogEntryPath.TYPE_ADDED;
        }
        return changeType;
    }

    @SuppressWarnings("unchecked")
    private static Revision guessMergedRevision(String targetPath, SVNLogEntry mergedRevision) {
        Map<String, SVNLogEntryPath> changedPaths = mergedRevision.getChangedPaths();
        for (SVNLogEntryPath lep : changedPaths.values()) {
            String[] targetSplit = FileNameUtil.splitPath(targetPath);
            String[] mergeSplit = FileNameUtil.splitPath(lep.getPath());
            if (targetSplit != null && mergeSplit != null && !targetSplit[1].equals(mergeSplit[1])) {
                return new Revision(mergeSplit[0] + mergeSplit[1] + targetSplit[2], mergedRevision.getRevision());
            }
        }
        return null;
    }

    private static void showProgressInfo(ProgressIndicator progressIndicator, String message) {
        if (progressIndicator != null) {
            progressIndicator.setText2(message);
        }
    }

    private static void checkCancelled(ProgressIndicator progressIndicator) throws SVNCancelException {
        if (progressIndicator.isCanceled()) {
            throw new SVNCancelException();
        }
    }

    public SVNProperties getFileProperties(Revision revision) throws SVNException {
        SVNInfo info = svn.getInfo(file);
        if (info == null) {
            throw new SVNException(SVNErrorMessage.create(SVNErrorCode.UNKNOWN, "Error retrieving SVN information for file"));
        }
        String repoRoot = info.getRepositoryRootURL().toString();
        SVNRepository repo = svn.createRepository(repoRoot);
        SVNProperties properties = new SVNProperties();
        if (info.getKind() == SVNNodeKind.FILE) {
            repo.getFile(revision.getRelPath(), revision.getRevisionNumber(), properties, null);
        } else {
            repo.getDir(revision.getRelPath(), revision.getRevisionNumber(), properties, (Collection) null);
        }
        return properties.getRegularProperties();
    }
    
    private static class Task {
        public final String relPath;
        public final long revision;
        public final boolean errorIfAbsent;

        private Task(String relPath, long revision, boolean errorIfAbsent) {
            this.relPath = relPath;
            this.revision = revision;
            this.errorIfAbsent = errorIfAbsent;
        }

        @Override
        public String toString() {
            return relPath + "@" + revision;
        }

    }
    
    private static class CollectedRevisions {
        final Set<Revision> revisions = new HashSet<Revision>();
        final Map<String, List<List<Revision>>> chainsByPath = new HashMap<String, List<List<Revision>>>();

        public void addRevisionChain(List<Revision> revisionChain) throws SVNException {
            if (revisionChain.isEmpty()) {
                throw new SVNException(SVNErrorMessage.create(SVNErrorCode.UNKNOWN, "No revisions found"));
            }
            String relPath = revisionChain.get(0).getRelPath();
            List<List<Revision>> chainList = chainsByPath.get(relPath);
            if (chainList == null) {
                chainList = new LinkedList<List<Revision>>();
                chainsByPath.put(relPath, chainList);
            }
            ListIterator<List<Revision>> it = chainList.listIterator();
            long lastRev = revisionChain.get(revisionChain.size() - 1).getRevisionNumber();
            while (it.hasNext()) {
                List<Revision> chain = it.next();
                if (lastRev >= chain.get(0).getRevisionNumber()) {
                    it.previous();
                    addChain(revisionChain, it);
                    return;
                }
            }
            addChain(revisionChain, it);
        }
        
        private void addChain(List<Revision> revisionChain, ListIterator<List<Revision>> it) {
            it.add(revisionChain);
            for (Revision r : revisionChain) {
                if (revisions.contains(r)) {
                    if (r.getChangeType() == SVNLogEntryPath.TYPE_REPLACED) {
                        revisions.remove(r);
                    } else {
                        continue;
                    }
                }
                revisions.add(r);
            }
        }

        public boolean contain(String relPath, long revision) {
            return getActualRevision(relPath, revision) != null;
        }

        public Revision getActualRevision(String relPath, long revision) {
            List<List<Revision>> chainList = chainsByPath.get(relPath);
            if (chainList == null) {
                return null;
            }
            for (List<Revision> chain : chainList) {
                Revision firstRev = chain.get(chain.size() - 1);
                if (revision < firstRev.getRevisionNumber()) {
                    continue;
                }
                for (Revision r : chain) {
                    if (revision >= r.getRevisionNumber()) {
                        if (r.getChangeType() == SVNLogEntryPath.TYPE_DELETED) {
                            return null;
                        } else {
                            return r;
                        }
                    }
                }
            }
            return null;
        }
        
        public Collection<Revision> getRevisions() {
            // fix copy links
            List<Revision> result = new ArrayList<Revision>(revisions.size());
            for(Revision r : revisions) {
                if (r.getLinkType() == Revision.LinkType.COPY) {
                    result.add(new Revision(r.getRelPath(), r.getRevisionNumber(), 
                            r.getAuthor(), r.getDate(), r.getMessage(), 
                            Arrays.asList(getActualRevision(r.getLinkedRelPath(), r.getLinkedRevisionNumber())),
                            Revision.LinkType.COPY, r.getChangeType()));
                } else {
                    result.add(r);
                }
            }
            return result;
        }
    }

    private static interface LogEntryWithMergeInfoHandler {
        void handleLogEntry(SVNLogEntry logEntry, @NotNull List<Revision> mergedRevision) throws SVNCancelException;
    }

    private class MergeInfoProcessor implements ISVNLogEntryHandler {
        private final LogEntryWithMergeInfoHandler consumer;
        private final String relativePath;
        private int depth = 0;
        private SVNLogEntry savedEntry = null;
        private List<Revision> mergedRevisions;

        public MergeInfoProcessor(String relativePath, LogEntryWithMergeInfoHandler consumer) {
            this.relativePath = relativePath;
            this.consumer = consumer;
            mergedRevisions = new ArrayList<Revision>();
        }

        @Override
        @SuppressWarnings("unchecked")
        public void handleLogEntry(SVNLogEntry logEntry) throws SVNException {
            if (depth == 0) {
                savedEntry = logEntry;
            } else if (depth == 1 && logEntry.getRevision() != INVALID_REVISION) {
                Revision mergedRevision = guessMergedRevision(relativePath, logEntry);
                if (mergedRevision != null) {
                    mergedRevisions.add(mergedRevision);
                }
            }
            if (logEntry.hasChildren()) {
                depth++;
            } else if (logEntry.getRevision() == INVALID_REVISION) {
                if (--depth < 0) {
                    throw new MalformedSVNResponseException();
                }
            }
            if (depth == 0) {
                consumer.handleLogEntry(savedEntry, mergedRevisions);
                mergedRevisions = new ArrayList<Revision>();
            }
        }

        public void checkFinalState() throws SVNException {
            if (depth != 0) {
                throw new MalformedSVNResponseException();
            }
        }
    }

    private static class MalformedSVNResponseException extends SVNException {
        public MalformedSVNResponseException() {
            super(SVNErrorMessage.create(SVNErrorCode.UNKNOWN));
        }
    }

    private static class ExtendedScanManager {
        final SVNRepository repo;
        final long revision;
        final ProgressIndicator progressIndicator;
        final Set<String> processedTrunkPaths;
        final Map<String,Collection<String>> modulePaths;

        public ExtendedScanManager(SVNRepository repo, long revision, ProgressIndicator progressIndicator) {
            this.repo = repo;
            this.revision = revision;
            this.progressIndicator = progressIndicator;
            this.processedTrunkPaths = new HashSet<String>();
            this.modulePaths = new HashMap<String, Collection<String>>();
        }

        public Collection<Task> getPotentialTargets(String relPath, boolean includeTags) throws SVNCancelException {
            Collection<Task> result = new ArrayList<Task>();

            String[] pathSplit = FileNameUtil.splitPath(relPath);
            if (pathSplit == null) {
                log.info("Couldn't determine branch/tag locations for " + relPath + ", will skip extended scan for it");
                return result;
            }
            String trunkPath = pathSplit[0] + "/trunk" + pathSplit[2];
            if (!processedTrunkPaths.add(trunkPath)) {
                return result;
            }

            Collection<String> principalPaths = getPrincipalPathsForModule(pathSplit[0], includeTags);
            for (String p : principalPaths) {
                String path = p + pathSplit[2];
                result.add(new Task(path, revision, false));
            }
            return result;
        }

        private Collection<String> getPrincipalPathsForModule(String module, boolean includeTags) {
            Collection<String> paths = modulePaths.get(module);
            if (paths != null) {
                return paths;
            }
            
            showProgressInfo(progressIndicator, "Searching for branches and tags in " + module);
            
            paths = new ArrayList<String>();
            paths.add(module + "/trunk");
            Collection<SVNDirEntry> entries = new ArrayList<SVNDirEntry>();
            try {
                String branchesPath = module + "/branches/";
                repo.getDir(branchesPath, revision, false, entries);
                for (SVNDirEntry de : entries) {
                    if (de.getKind() == SVNNodeKind.DIR) {
                        paths.add(branchesPath + de.getRelativePath());
                    }
                }
            } catch (SVNException e) {
                log.warn("Error finding existing branches for module " + module, e);
            }
            if (includeTags) {
                entries.clear();
                try {
                    String tagsPath = module + "/tags/";
                    repo.getDir(tagsPath, revision, false, entries);
                    for (SVNDirEntry de : entries) {
                        if (de.getKind() == SVNNodeKind.DIR) {
                            paths.add(tagsPath + de.getRelativePath());
                        }
                    }
                } catch (SVNException e) {
                    log.warn("Error finding existing tags for module " + module, e);
                }
            }
            modulePaths.put(module, paths);
            return paths;
        }
    }
}
